昨天做出了最陽春的 PokeThreads,但故意跳過了一個問題:
GET /feed
這支 API 收到 Request 之後,到底該回傳哪些貼文?
先把問題講具體一點。假設 Pikachu 追蹤了 Charmander、Squirtle、Bulbasaur,那麼 Pikachu 打開 PokeThreads 的時候:
直覺的答案很單純:
找出 Pikachu 追蹤的人,把他們的貼文抓出來,按時間排序,新的放前面。
這個題型其實不陌生,跟 LeetCode 355 Design Twitter 要處理的問題幾乎一樣:發文、Follow、Unfollow、取得 News Feed,核心都是「找出自己與追蹤對象的貼文,按時間排序取前幾筆」。
先假設有這些資料:
Pikachu → Charmander
Pikachu → Squirtle
Pikachu → Bulbasaur
Charmander → Post A, Post B
Squirtle → Post C
Bulbasaur → Post D, Post E
要組出 Pikachu 的 Feed,可以拆成三步:
Pikachu
└── Following: [Charmander, Squirtle, Bulbasaur]
Charmander → Post A, Post B
Squirtle → Post C
Bulbasaur → Post D, Post E
Post E, Post C, Post B, Post D, Post A (由新到舊)
畫成流程就是:

寫成 SQL 大概長這樣:
SELECT *
FROM posts
WHERE author_id IN (
SELECT followee
FROM follows
WHERE follower = :user_id
)
ORDER BY created_at DESC
LIMIT 20;
這支 Query 一次做了三件事:
如果要把自己的貼文也放進 Feed,只要把 :user_id 一併加進 author_id 的條件即可。
這種「使用者打開 Feed 的當下,才去即時查詢、組出結果」的做法,就是最基礎的 Fan-out on Read。
上面的 Query 加了 LIMIT 20,但使用者往下滑的時候呢?總不能每次都把 Pikachu 追蹤對象的所有貼文一次撈出來,這就需要 Pagination(分頁)。
最直覺的做法:
GET /feed?offset=0&limit=20
GET /feed?offset=20&limit=20
GET /feed?offset=40&limit=20
對應的 SQL:
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20
OFFSET 20;
概念就是「跳過前 N 筆,再取下一批」,很好懂,但 Feed 有個麻煩的特性:它會一直有新資料進來。
假設 Pikachu 在 10:00 拿到第一頁 [Post A, Post B, Post C],這時候 Charmander 發了新文章 Post X,Feed 變成 [Post X, Post A, Post B, Post C]。如果 Pikachu 接著用 OFFSET 3 拿下一頁,因為前面多插入了一筆資料,位置全部往後移了一格,就可能重複拿到 Post C,或漏掉某一篇。
另一種做法不是「跳過幾筆」,而是「從上次拿到的最後一筆位置,繼續往後拿」。
第一次:
GET /feed?limit=20
Server 回傳:
{
"posts": [...],
"next_cursor": "abc123"
}
下一次帶著 cursor 繼續拿:
GET /feed?cursor=abc123&limit=20
Cursor 通常不是單純的頁碼,而是「上一筆資料的定位資訊」,例如用 created_at + id 當作依據:
SELECT *
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
重點是 Server 不需要知道「現在是第幾頁」,只需要知道「上一批資料停在哪裡」,新資料插進來也不會打亂已經翻過的頁面。
| Offset Pagination | Cursor Pagination | |
|---|---|---|
| 概念 | 跳過前 N 筆 | 從某個位置繼續 |
| 實作難度 | 簡單 | 稍微複雜 |
| 面對動態資料 | 容易錯位(重複 / 漏資料) | 相對穩定 |
| 支援跳頁 | 可以 | 不適合 |
| 常見場景 | 後台列表、資料量小的頁面 | 動態 Feed、無限捲動 |
像 PokeThreads 這種持續有新內容產生的 Feed,業界普遍會選擇 Cursor Pagination,而不是 Offset Pagination。
目前的 Feed 完全建立在昨天的架構上:
瀏覽器 -> GET /feed -> Server -> Database
Server 收到 Request 後,靠 Follow 資料表找出追蹤對象,靠 Post 資料表撈出貼文,全部工作都交給 Database 的一次 Query 完成。
今天把 Feed 從「一個沒有實作的 API」,補成一個看得懂、也解釋得出為什麼這樣做的功能:
找出 Follow 對象
↓
撈出他們的 Post
↓
依時間排序
↓
用 Cursor 做分頁
不過這個 GET /feed 目前回傳的還是整包固定格式的 JSON,這種溝通方式在單純的 Web 前端上很夠用,但如果之後要支援更多不同型態的前端(例如只想要某幾個欄位、或是想一次拿到使用者資訊加上貼文),現在這種 API 設計方式撐不撐得住,就要看前後端之間到底怎麼溝通,這是明天要討論的 REST vs GraphQL。